网络编程是程序员比较少接触的技能,但是网络协议的应用却是非常常见的,比如 RPC 框架如 Dubbo 使用 Netty 进行通信,而 Netty 是一个网络编程框架,服务提供端和服务消费端正是通过 Netty 这个网络框架提供的机制进行通信的,再比如 Spring Clound 使用 HTTP 协议作为服务提供端和服务消费端的通信协议,甚至前端同学和后端同学进行联调的时候也需要熟悉 HTTP 协议。
HTTP 响应码是 Web 服务器告诉客户端当前服务器的运行状况的标识。
当浏览器执行 JS 脚本的时候,会检测脚本要访问的协议、域名、端口号是不是和当前网址一致,如果不一致就是跨域。跨域是不允许的,这种限制叫做浏览器的同源策略,简单点的说法就是浏览器不允许一个源中加载脚本与其他源中的资源进行交互。那么如何实现跨域呢?
script、img、iframe、link、video、audio 等带有 src 属性的标签可以跨域请求和执行资源,JSONP 利用这一点“漏洞”实现跨域。
<script>
var scriptTag = document.createElement('script');
scriptTag.type = "text/javascript";
scriptTag.src = "http://10.10.0.101:8899/jsonp?callback=f";
document.head.appendChild(scriptTag);
</script>
再看下 jQuery 的写法。
$.ajax({
// 请求域名
url:'http://10.10.0.101:8899/login',
// 请求方式
type:'GET',
// 数据类型选择 jsonp
dataType:'jsonp',
// 回调方法名
jsonpCallback:'callback',
});
// 回调方法
function callback(response) {
console.log(response);
}
JSONP 实现跨域很简单但是只支持 GET 请求方式。而且在服务器端接受到 JSONP 请求后需要设置请求头,添加
Access-Control-Allow-Origin 属性,属性值为 *
,表示允许所有域名访问,这样浏览器才会正常解析,否则会报
406 错误。
response.setHeader("Access-Control-Allow-Origin", "*");
CORS(Cross-Origin Resource Sharing)即跨域资源共享,需要浏览器和服务器同时支持,这种请求方式分为简单请求和非简单请求。
当浏览器发出的 XMLHttpRequest 请求的请求方式是 POST 或者 GET,请求头中只包含 Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type(application/x-www-form-urlencoded、multipart/form-data、text/plain)时那么这个请求就是一个简单请求。
对于简单的请求,浏览器会在请求头中添加 Origin 属性,标明本次请求来自哪个源(协议 + 域名 + 端口)。
GET
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
Content-Type: text/plain
如果 Origin 标明的域名在服务器许可范围内,那么服务器就会给出响应:
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示所有域名都可以访问
Access-Control-Allow-Origin: http://127.0.0.1:8080
// 表示服务器是否同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定 XMLHttpRequest#getResponseHeader() 方法可以获取到的字段
Access-Control-Expose-Headers: xxx
Content-Type: text/html; charset=utf-8
Access-Control-Allow-Credentials: true
表示服务器同意浏览器发送
cookie,另外浏览器也需要设置支持发送 cookie,否则就算服务器支持浏览器也不会发送。
var xhr = new XMLHttpRequest();
// 设置发送的请求是否带 cookie
xhr.withCredentials = true;
xhr.open('post', 'http://10.10.0.101:8899/login', true);
xhr.setRequestHeader('Content-Type', 'text/plain');
另外一种是非简单请求,请求方式是 PUT 或 DELETE,或者请求头中添加了 Content-Type:application/json 属性和属性值的请求。
这种请求在浏览器正式发出 XMLHttpRequest 请求前会先发送一个预检 HTTP 请求,询问服务器当前网页的域名是否在服务器的许可名单之中,只有得到服务器的肯定后才会正式发出通信请求。
预检请求的头信息:
// 预检请求的请求方式是 OPTIONS
OPTIONS
// 标明本次请求来自哪个源(协议+域名+端口)
Origin: http://127.0.0.1:8080
// 标明接下来的 CORS 请求要使用的请求方式
Access-Control-Request-Method: PUT
// 标明接下来的 CORS 请求要附加发送的头信息属性
Access-Control-Request-Headers: X-Custom-Header
// IP
Host: 127.0.0.1:8080
// 长连接
Connection: keep-alive
如果服务器回应预检请求的响应头中没有任何 CORS 相关的头信息的话表示不支持跨域,如果允许跨域就会做出响应,响应头信息如下:
HTTP/1.1 200 OK
// 该值上文提到过,表示允许浏览器指定的域名访问,要么为浏览器传入的 origin,要么为 * 表示所有域名都可以访问
Access-Control-Allow-Origin:http://127.0.0.1:8080
// 服务器支持的所有跨域请求方式,为了防止浏览器发起多次预检请求把所有的请求方式返回给浏览器
Access-Control-Allow-Methods: GET, POST, PUT
// 服务器支持预检请求头信息中的 Access-Control-Request-Headers 属性值
Access-Control-Allow-Headers: X-Custom-Header
// 服务器同意浏览器发送 cookie
Access-Control-Allow-Credentials: true
// 指定预检请求的有效期是 20 天,期间不必再次发送另一个预检请求
Access-Control-Max-Age:1728000
Content-Type: text/html; charset=utf-8
Keep-Alive: timeout=2, max=100
// 长连接
Connection: Keep-Alive
Content-Type: text/plain
接着浏览器会像简单请求一样,发送一个 CORS 请求,请求头中一定包含 Origin 属性,服务器的响应头中也一定得包含 Access-Control-Allow-Origin 属性。
跨域限制是浏览器的同源策略导致的,使用 nginx 当做服务器访问别的服务的 HTTP 接口是不需要执行 JS 脚步不存在同源策略限制的,所以可以利用 Nginx 创建一个代理服务器,这个代理服务器的域名跟浏览器要访问的域名一致,然后通过这个代理服务器修改 cookie 中的域名为要访问的 HTTP 接口的域名,通过反向代理实现跨域。
Nginx 的配置信息:
server {
# 代理服务器的端口
listen 88;
# 代理服务器的域名
server_name http://127.0.0.1;
location / {
# 反向代理服务器的域名+端口
proxy_pass http://127.0.0.2:89;
# 修改cookie里域名
proxy_cookie_domain http://127.0.0.2 http://127.0.0.1;
index index.html index.htm;
# 设置当前代理服务器允许浏览器跨域
add_header Access-Control-Allow-Origin http://127.0.0.1;
# 设置当前代理服务器允许浏览器发送 cookie
add_header Access-Control-Allow-Credentials true;
}
}
前端代码:
var xhr = new XMLHttpRequest();
// 设置浏览器允许发送 cookie
xhr.withCredentials = true;
// 访问 nginx 代理服务器
xhr.open('get', 'http://127.0.0.1:88', true);
xhr.send();
用途:
表单的提交方式:
name1=value1&name2=value2
的形式拼接到 URL
上(http://www.baidu.com/action?name1=value1&name2=value2),多个参数参数值需要用
& 连接起来并且用 ?
拼接到 action 后面;
传输数据的大小限制:
参数的编码:
缓存:
TCP 和 UDP 都是传输层的网络协议,主要用途在于不同服务器的应用进程间的通信。
连接维护:
可靠性:
数据传输方式:
接下来通过执行 UDP 服务端和客户端通信的测试用例和 TCP 服务端和客户端通信的测试用例,更直观的了解下这两种通信方式的区别。
UDP 服务端代码:
// UDPServerTest.java
public class UDPServerTest {
public static void main(String[] args) throws Exception {
DatagramSocket serverSocket = new DatagramSocket(8888);
byte[] readyToSendData;
SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
while (true) {
byte[] receiveData = new byte[1024];
// 创建接收数据报,接受来自客户端的数据
DatagramPacket fromClientDataPacket = new DatagramPacket(receiveData, receiveData.length);
// 监听客户端是否发送了数据包
serverSocket.receive(fromClientDataPacket);
// 获取客户端数据包的内容
String data = new String(fromClientDataPacket.getData());
// 获取客户端 IP 地址
InetAddress address = fromClientDataPacket.getAddress();
if (data != null) {
System.out.println("【"+formatter.format(new Date()) + "】 receive data from client[" + address + "]: " + data );
}
// 获得客户端端口号
int port = fromClientDataPacket.getPort();
// 将获取到的数据包的内容转为大写
String upperData = data.toUpperCase();
readyToSendData = upperData.getBytes();
// 创建发送数据报,用来向客户端发送数据
DatagramPacket readyToSendPacket = new DatagramPacket(readyToSendData, readyToSendData.length, address, port);
//向客户端发送数据报包
serverSocket.send(readyToSendPacket);
}
}
}
UDP 客户端代码:
// UDPClientTest.java
public class UDPClientTest {
public static void main(String[] args) throws Exception {
DatagramSocket clientSocket = new DatagramSocket();
// 监听 console 的文字输入
BufferedReader inputFromConsole = new BufferedReader(new InputStreamReader(System.in));
// 获取 client 端的 IP 地址
InetAddress adress = InetAddress.getLocalHost();
byte[] readyToSendData;
byte[] receiveData = new byte[1024];
while (true) {
String input = inputFromConsole.readLine();
if (input.equals("exit")) break;
readyToSendData = input.getBytes();
// 创建发送数据报,用来向服务端发送数据
DatagramPacket readyToSendPacket = new DatagramPacket(readyToSendData, readyToSendData.length, adress, 8888);
//发送数据报包
clientSocket.send(readyToSendPacket);
// 创建接收数据报,接收来自服务端的数据
DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
// 监听服务端是否发来了数据包
clientSocket.receive(receivePacket);
String data = new String(receivePacket.getData());
System.out.println("Server reply: " + data);
}
clientSocket.close();
}
}
UDP 服务端执行结果:
UDP 客户端执行结果:
TCP 服务端代码:
// TCPServerTest.java
public class TCPServerTest {
public static void main(String[] args) throws Exception {
String data;
String upperData;
SimpleDateFormat dataFormatter = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
ServerSocket serverSocket = new ServerSocket(8888);
while(true) {
// 接受客户端的连接
Socket socket = serverSocket.accept();
// 输入流,保存接收到的数据
BufferedReader isFromClient = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 输出流,用于向外发送数据
DataOutputStream dos = new DataOutputStream(socket.getOutputStream());
// 获取客户端数据
data = isFromClient.readLine();
if (data != null) {
System.out.println("【" + dataFormatter.format(new Date()) + "】 receive data from client[" + socket.getInetAddress() + "]: " + data );
}
upperData = data.toUpperCase() + '\n';
//向客户端发送修改后的字符串
dos.writeBytes(upperData);
}
}
}
TCP 客户端代码:
// TCPClientTest.java
public class TCPClientTest {
public static void main(String[] args) throws Exception {
String input;
String data;
while (true) {
// 监听 console 的文字输入
BufferedReader inputFromConsole = new BufferedReader(new InputStreamReader(System.in));
Socket clientSocket = new Socket("127.0.0.1", 8888);
// 输出流,用于向外发送数据
DataOutputStream readyToSendDos = new DataOutputStream(clientSocket.getOutputStream());
// 输入流,保存接收到的数据
BufferedReader receiveFromServer = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
input = inputFromConsole.readLine();
if (input.equals("exit")) break;
// 向服务器发送数据
readyToSendDos.writeBytes(input + '\n');
// IO 阻塞等待服务端的响应
data = receiveFromServer.readLine();
System.out.println("Server reply: " + data);
clientSocket.close();
}
}
}
TCP 服务端执行结果:
TCP 客户端执行结果:
seq=x
的初始序列号,此时客户端处于“同步已发送”的状态;
ack=x+1
表示客户端可以发送下一个数据包序号从 x+1
开始,同时选择
seq=y
的初始序列号,此时服务端处于“同步收到”状态;
ack=y+1
表示服务端可以发送下一个数据包序号从 y+1
开始,此时客户端处于“已建立连接”的状态;
从三次握手的过程可以看出如果只有两次握手,那么客户端的起始序列号可以确认,服务端的起始序列号将得不到确认。
上文中讲 TCP 和 UDP 区别的时候提到 TCP 传输数据基于字节流,从应用层到 TCP 传输层的多个数据包是一连串的字节流是没有边界的,而且 TCP 首部并没有记录数据包的长度,所以 TCP 传输数据的时候可能会发送粘包和拆包的问题;而 UDP 是基于数据报传输数据的,UDP 首部也记录了数据报的长度,可以轻易的区分出不同的数据包的边界。
接下来看下 TCP 传输数据的几种情况,首先第一种情况是正常的,既没有发送粘包也没有发生拆包。
第二种情况发生了明显的粘包现象,这种情况对于数据接收方来说很难处理。
接下来的两种情况发生了粘包和拆包的现象,接收端收到的数据包要么是不完整的要么是多出来一块儿。
造成粘包和拆包现象的原因:
粘包拆包的解决方法:
序列号和确认号机制:
TCP 发送端发送数据包的时候会选择一个 seq 序列号,接收端收到数据包后会检测数据包的完整性,如果检测通过会响应一个 ack 确认号表示收到了数据包。
超时重发机制:
TCP 发送端发送了数据包后会启动一个定时器,如果一定时间没有收到接受端的确认后,将会重新发送该数据包。
对乱序数据包重新排序:
从 IP 网络层传输到 TCP 层的数据包可能会乱序,TCP 层会对数据包重新排序再发给应用层。
丢弃重复数据:
从 IP 网络层传输到 TCP 层的数据包可能会重复,TCP 层会丢弃重复的数据包。
流量控制:
TCP 发送端和接收端都有一个固定大小的缓冲空间,为了防止发送端发送数据的速度太快导致接收端缓冲区溢出,发送端只能发送接收端可以接纳的数据,为了达到这种控制效果,TCP 用了流量控制协议(可变大小的滑动窗口协议)来实现。
先来看下流量控制的原理,下图为 TCP 发送端的发送情况:
可以看到滑动窗口的固定大小是 5,滑动窗口的大小等于未确认的包 + 已经准备好但是未发送的包,每次收到 ACK 就会增加一个红色的数据包,然后滑动窗口向右移动一位,即最左边的绿色数据包移出滑动窗口变成红色,下一个紫色的数据包进入滑动窗口变成蓝色准备发送。
再看下接收端的接收情况:
在接收端这边可以看到滑动窗口内有一个虚线的数据包,表示该数据包还没有收到,该数据包可能发生了丢包的情况或者乱序了,这个时候接收端会先将该数据包缓存着,等待发送端超时重发。
当发送端发送数据包速度过快,导致接收端的缓存空间不够用,或者接收端一直不读取缓存空间中的数据包,接收端可以改变滑动窗口的大小甚至变为 0,让发送端停止发送数据包,从下图可以看出滑动窗口并没有向右移动,而是滑动窗口左边界向右移动了一格。
这种情况下接收端的滑动窗口的大小变成了 0,发送端也不再发送新的数据包,但是发送端会定时发送窗口探测数据包,和接收端商量是否可以改变滑动窗口的大小,接收端在窗口大小比较小的情况下不会立马回复发送端可以改变窗口大小,而是等到接收端的缓存空间为空的时候再改变窗口大小。
接下来再分析下拥塞控制的原理,先看下图这种极端情况,管道中能容纳 6 个数据包,有 3 个未接收的数据包和 3 个 ack 数据包,正好把管道撑满,如果再往管道中发送数据,那么数据包会被缓存在设备中增加时延或者丢弃多余的包,而 TCP 面对丢包的问题只能重发数据,重发数据又导致了更严重的延迟和更多的丢包,从而造成恶心循环,而拥塞控制就是为了处理时延带来的超时重传和丢包问题的。
先来看下拥塞窗口和发送端实际可用的窗口大小的联系:
如下图所示,拥塞控制一共有 4 个算法。
慢开始:
拥塞避免:
当 cwnd 增长过快并且超过了 ssthresh 阈值的话,为了防止网络拥塞,将会进入拥塞避免状态,cwnd 呈现线性增长。
超时重传:
当遇到超时重传导致的丢包的时候,ssthresh = 当前 cwnd/2,cwnd 值重置为 1,然后重新进入慢开始状态。
快速恢复:
\1. 应用层的 DNS 域名解析协议将网址解析为 IP 地址。
浏览器会先查看缓存中有没有 DNS 解析过的这个 IP 地址,如果有的话就不会查询 DNS 服务;如果没有就查看操作系统的 hosts 文件是 否有 DNS 解析过的这个 IP 地址;如果还是没有的话就会向 DNS 服务器发送数据包对域名进行解析,找到解析后的 IP 地址后返回客户端。
\2. 浏览器得到网址对应的服务器的 IP 地址后,通过与服务器三次握手建立 TCP 连接。
\3. 浏览器与服务器建立好 TCP 连接后,就会发送 HTTP 请求。
\4. 服务器处理浏览器发送的 HTTP 请求。
浏览器向 Web 服务器如 Nginx 发送 HTTP 请求,Nginx 将请求转发给 Tomcat 服务器,Tomcat 服务器请求 MySQL、Redis 等 DB 服务器,得到结果后将 Velocity 模板引擎和数据整合,将生成的静态页面文件通过 Nginx 返回给浏览器。
\5. 服务器返回响应结果。
在响应头中返回了 HTTP 状态码、HTTP 协议版本、是否为长连接、文本编码类型、日期等等。
\6. 浏览器和者服务器通过四次挥手关闭 TCP。
\7. 浏览器解析 HTML、CSS、JS 等进行页面渲染。
大致上分为公共地址和私有地址两大类,公共地址可以在外网中随意访问,私有地址只能在内网访问只有通过代理服务器才可以和外网通信。
公共地址:
1.0.0.1~126.255.255.254
128.0.0.1~191.255.255.254
192.0.0.1~223.255.255.254
224.0.0.1~239.255.255.254
240.0.0.1~255.255.255.254
私有地址:
10.0.0.0~10.255.255.255
172.16.0.0~172.31.255.255
192.168.0.0~192.168.255.255
网络编程在最近几年的面试题中出现的频率越来越频繁,深入理解网络通信协议也将变得刻不容缓。